/*
* Copyright 2016 John Grosh (jagrosh).
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package vortex;
import java.time.OffsetDateTime;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import net.dv8tion.jda.core.EmbedBuilder;
import net.dv8tion.jda.core.JDA;
import net.dv8tion.jda.core.Permission;
import net.dv8tion.jda.core.entities.Guild;
import net.dv8tion.jda.core.entities.Invite;
import net.dv8tion.jda.core.entities.Member;
import net.dv8tion.jda.core.entities.Message;
import net.dv8tion.jda.core.entities.Role;
import net.dv8tion.jda.core.entities.TextChannel;
import net.dv8tion.jda.core.entities.User;
import net.dv8tion.jda.core.events.ReadyEvent;
import net.dv8tion.jda.core.events.ShutdownEvent;
import net.dv8tion.jda.core.events.guild.member.GuildMemberJoinEvent;
import net.dv8tion.jda.core.events.guild.member.GuildMemberRoleAddEvent;
import net.dv8tion.jda.core.events.message.guild.GuildMessageReceivedEvent;
import net.dv8tion.jda.core.events.message.guild.GuildMessageUpdateEvent;
import net.dv8tion.jda.core.exceptions.PermissionException;
import net.dv8tion.jda.core.hooks.ListenerAdapter;
import net.dv8tion.jda.core.requests.restaction.AuditableRestAction;
import net.dv8tion.jda.core.utils.PermissionUtil;
import net.dv8tion.jda.core.utils.SimpleLog;
import vortex.data.DMSpamManager;
import vortex.data.DatabaseManager;
import vortex.data.DatabaseManager.GuildSettings;
/**
*
* @author John Grosh (jagrosh)
*/
public class AutoMod extends ListenerAdapter {
private final Pattern INVITES = Pattern.compile("discord(?:\\.gg|app.com\\/invite)\\/([A-Z0-9_]{2,16})",Pattern.CASE_INSENSITIVE);
public static final int MENTION_MINIMUM = 6;
private final SimpleLog LOG = SimpleLog.getLog("AutoMod");
private final HashMap<String,OffsetDateTime> warnings = new HashMap<>();
private final HashMap<String,SpamStatus> spams = new HashMap<>();
private final HashMap<String,RaidmodeStatus> raids = new HashMap<>();
private final HashMap<String,StringBuilder> raidmode = new HashMap<>();
private final ModLogger modlog;
private final ScheduledExecutorService threadpool;
private final DatabaseManager manager;
private final DMSpamManager dmspam;
private JDA jda;
public AutoMod(ModLogger modlog, ScheduledExecutorService threadpool, DatabaseManager manager, DMSpamManager dmspam)
{
this.modlog = modlog;
this.threadpool = threadpool;
this.manager = manager;
this.dmspam = dmspam;
dmspam.registerAutoMod(this);
}
public String getSettings(Guild guild)
{
GuildSettings gs = manager.getSettings(guild);
TextChannel channel = guild.getTextChannelById(gs.modlogChannelId);
return "AntiMention: "+(gs.maxMentions>MENTION_MINIMUM ? "**"+gs.maxMentions+"**" : "not enabled")
+"\nAntiInvite: "+(gs.inviteAction!=Action.NONE ? "**"+gs.inviteAction.name()+"**" : "not enabled")
+"\nAntiSpam: "+(gs.spamLimit>0 && gs.spamAction!=Action.NONE ? "**"+gs.spamAction.name()+" on "+gs.spamLimit+"**" : "not enabled")
+"\nModlog Channel: "+(channel==null ? "not enabled" : channel.getAsMention())
+"\nAuto RaidMode: "+(gs.autoRaidMode ? "**Enabled**" : "not enabled")
+"\nAntiDMSpam: "+(gs.blockDmSpam ? "**Enabled**" : "not enabled")
;
}
@Override
public void onShutdown(ShutdownEvent event) {
threadpool.shutdown();
manager.shutdown();
}
public void onDMSpamGuildJoin(User u) {
if(jda==null)
return;
User user = jda.getUserById(u.getId());
if(user==null)
return;
List<Guild> spammers = dmspam.getDMSpamServers(user);
List<Guild> kicks = manager.getDMSpamPreventionGuilds(jda);
if(kicks.isEmpty())
return;
kicks = kicks.stream().filter((Guild g) -> {
Member m = g.getMemberById(user.getId());
if(m==null)
return false;
return shouldPerformAutomod(m, null);
}).collect(Collectors.toList());
if(kicks.isEmpty())
return;
StringBuilder sb = new StringBuilder();
sb.append("Sorry, it looks like you are a member of the following server(s):\n");
spammers.forEach(g -> sb.append("\n **").append(g.getName()).append("**"));
sb.append("\n\nThese server(s) are known to be the origin of large amounts of direct-message "
+ "spam and advertisements. Therefore, you will be kicked from the following servers in "
+ "**10** minutes unless you leave the above servers:\n");
kicks.forEach(g -> sb.append("\n **").append(g.getName()).append("**"));
try {
user.openPrivateChannel().queue(pc -> pc.sendMessage(sb.toString()).queue());
} catch(Exception e) {}
threadpool.schedule(() -> {
removeDMSpammer(user);
}, 10, TimeUnit.MINUTES);
}
private void removeDMSpammer(User user) {
List<Guild> spammers = dmspam.getDMSpamServers(user);
List<Guild> kicks = manager.getDMSpamPreventionGuilds(jda);
if(kicks.isEmpty() || spammers.isEmpty())
return;
kicks = kicks.stream().filter((Guild g) -> {
Member m = g.getMemberById(user.getId());
if(m==null)
return false;
return shouldPerformAutomod(m, null);
}).collect(Collectors.toList());
if(kicks.isEmpty())
return;
kicks.forEach(g -> {
try{
g.getController().kick(user.getId()).reason("Being on a DM-spam server.").queue();
modlog.logAutomod(g.getMemberById(user.getId()), Action.KICK, "Joining a DM-spam server: "+spammers.get(0).getName()+" ("+spammers.get(0).getId()+")");
} catch(Exception e) {}
});
}
@Override
public void onGuildMemberJoin(GuildMemberJoinEvent event) {
if(event.getMember().getUser().isBot())
return;
if(manager.isDMSpamPrevention(event.getGuild()) && event.getGuild().getSelfMember().hasPermission(Permission.KICK_MEMBERS))
{
List<Guild> guilds = dmspam.getDMSpamServers(event.getMember().getUser());
if(!guilds.isEmpty())
{
StringBuilder sb = new StringBuilder();
sb.append("Sorry, you cannot join **").append(event.getGuild().getName())
.append("** because you are a member of the following server(s):\n");
guilds.forEach((g) -> {
sb.append("\n **").append(g.getName()).append("**");
});
sb.append("\n\nThe listed server(s) are known to be the origin of large amounts of direct-message spam "
+ "and advertisements, and therefore all members are prevented from joining. Please leave those "
+ "server(s) if you would like to join **").append(event.getGuild().getName()).append("**.");
sendPrivateMessageThen(event.getMember().getUser(), sb.toString(), () -> {
try{
event.getGuild().getController().kick(event.getMember()).reason("Being on a DM-spam server.").queue();
modlog.logAutomod(event.getMember(), Action.KICK, "Being on a DM-spam server: "+guilds.get(0).getName()+" ("+guilds.get(0).getId()+")");
}catch(PermissionException ex){}
});
return;
}
}
if(raidmode.keySet().contains(event.getGuild().getId()))
{
if(event.getGuild().getSelfMember().hasPermission(Permission.KICK_MEMBERS))
{
sendPrivateMessageThen(event.getMember().getUser(), "Sorry, **"+event.getGuild().getName()+"** is currently under lockdown. Please try joining again later. Sorry for the inconvenience.",
() -> event.getGuild().getController().kick(event.getMember()).reason("Anti-Raid Mode").queue());
raidmode.get(event.getGuild().getId()).append(" <@").append(event.getMember().getUser().getId()).append(">");
}
}
if(manager.isAutoRaidMode(event.getGuild()))
{
RaidmodeStatus rs = raids.get(event.getGuild().getId());
if(rs==null)
{
rs = new RaidmodeStatus();
raids.put(event.getGuild().getId(), rs);
}
else
rs.update();
if(rs.count > 9)
{
startRaidMode(event.getGuild(), null);
}
}
}
public boolean startRaidMode(Guild guild, Message iniator)
{
if(raidmode.keySet().contains(guild.getId()))
return false;
raidmode.put(guild.getId(), new StringBuilder("Disabled Raid Mode. Users kicked:\n"));
if(iniator==null)
{
scheduleRaidModeCheck(guild);
modlog.logEmbed(guild, new EmbedBuilder()
.setColor(guild.getSelfMember().getColor())
.setDescription("Raid mode automatically enabled. Verification will be set to maximum if possible, and any user that joins will be kicked.")
.setTimestamp(OffsetDateTime.now())
.setFooter(guild.getJDA().getSelfUser().getName()+" automod", guild.getJDA().getSelfUser().getEffectiveAvatarUrl())
.build());
}
else
modlog.logCommand(iniator);
try{
guild.getManager().setVerificationLevel(Guild.VerificationLevel.HIGH).queue();
} catch(PermissionException ex){}
return true;
}
public boolean endRaidMode(Guild guild)
{
StringBuilder sb = raidmode.remove(guild.getId());
if(sb==null)
return false;
modlog.logMessage(guild, sb.toString());
return true;
}
public boolean isRaidModeEnabled(Guild guild)
{
return raidmode.containsKey(guild.getId());
}
public void shutdownAllRaidMode(JDA jda)
{
raidmode.keySet().forEach(id -> {
Guild guild = jda.getGuildById(id);
if(guild!=null)
endRaidMode(guild);
});
}
public void scheduleRaidModeCheck(Guild guild)
{
threadpool.schedule(()->{
if(!raidmode.keySet().contains(guild.getId()))
{
RaidmodeStatus rs = raids.get(guild.getId());
if(rs!=null)
{
if(rs.secondSinceLastJoin()>240)
endRaidMode(guild);
else
scheduleRaidModeCheck(guild);
}
}
}, 2, TimeUnit.MINUTES);
}
@Override
public void onGuildMessageUpdate(GuildMessageUpdateEvent event) {
performAutomod(event.getMessage());
}
@Override
public void onGuildMessageReceived(GuildMessageReceivedEvent event) {
performAutomod(event.getMessage());
}
private boolean shouldPerformAutomod(Member member, TextChannel channel)
{
// ignore users not in the guild
if(member==null || member.getGuild()==null)
return false;
// ignore bots
if(member.getUser().isBot())
return false;
// ignore users vortex cant interact with
if(!PermissionUtil.canInteract(member.getGuild().getSelfMember(), member))
return false;
// ignore users that can kick
if(member.hasPermission(Permission.KICK_MEMBERS))
return false;
// ignore users that can ban
if(member.hasPermission(Permission.BAN_MEMBERS))
return false;
// ignore users that can manage server
if(member.hasPermission(Permission.MANAGE_SERVER))
return false;
// if a channel is specified, ignore users that can manage messages in that channel
if(channel!=null && member.hasPermission(channel, Permission.MESSAGE_MANAGE))
return false;
// ignore members with a role called 'vortexshield'
if(member.getRoles().stream().anyMatch(r -> r.getName().toLowerCase().equals("vortexshield")))
return false;
if(manager.isIgnored(channel))
return false;
if(manager.isIgnored(member))
return false;
return true;
}
public void performAutomod(Message message)
{
//simple automod
//ignore users with Manage Messages, Kick Members, Ban Members, Manage Server, or anyone the bot can't interact with
if(!shouldPerformAutomod(message.getMember(), message.getTextChannel()))
return;
//get the settings
GuildSettings settings = manager.getSettings(message.getGuild());
if(settings==null)
return;
/*
check for automod actions
* AntiSpam - prevent repeated messages
* AntiMention - prevent mass-mention spammers
* AntiInvite - prevent invite links to other servers
*/
boolean shouldDelete = false;
// anti-spam
if(settings.spamAction!=Action.NONE && (message.getTextChannel().getTopic()==null || !message.getTextChannel().getTopic().toLowerCase().contains("{spam}")))
{
String key = message.getAuthor().getId()+"|"+message.getGuild().getId();
SpamStatus status = spams.get(key);
if(status==null)
{
spams.put(key, new SpamStatus(message));
}
else
{
int offenses = status.update(message);
switch(offenses) {
case 0:
case 1:
case 2:
break;
case 3:
shouldDelete = true;
break;
case 4:
shouldDelete = true;
message.getTextChannel().sendMessage(message.getMember().getAsMention()+": Please stop spamming. Your messages have been removed.").queue();
break;
default:
if(offenses >= settings.spamLimit)
{
AuditableRestAction ra = null;
switch(settings.spamAction) {
case BAN:
ra = message.getGuild().getController().ban(message.getMember(), 1);
break;
case KICK:
shouldDelete = true;
ra = message.getGuild().getController().kick(message.getMember());
break;
case MUTE:
shouldDelete = true;
Role mutedRole = ModLogger.getMutedRole(message.getGuild());
if(mutedRole!=null)
ra = message.getGuild().getController().addRolesToMember(message.getMember(), mutedRole);
break;
case DELETE:
shouldDelete = true;
break;
}
if(ra!=null)
{
ra.reason("Spamming: "+message.getRawContent());
ra.queue(v -> modlog.logAutomod(message, settings.spamAction, "spamming: ```\n"+message.getRawContent()+" ```"));
}
}
}
}
}
// anti-mention
long mentions = message.getMentionedUsers().stream().filter(u -> !u.isBot() && !u.equals(message.getAuthor())).count();
if(mentions >= settings.maxMentions && mentions >= MENTION_MINIMUM)
{
try{
message.getGuild().getController().ban(message.getMember(), 1).reason("Mentioning "+mentions+" users.").queue(v -> {
modlog.logAutomod(message, Action.BAN, "mentioning **"+mentions+"** users.");
});
message.getTextChannel().sendMessage(message.getAuthor().getAsMention()+" has been banned for mentioning "+mentions+" users");
} catch(Exception e){}
}
// anti-invite
if(settings.inviteAction!=Action.NONE && (message.getTextChannel().getTopic()==null || !message.getTextChannel().getTopic().toLowerCase().contains("{invites}")))
{
List<String> invites = new ArrayList<>();
Matcher m = INVITES.matcher(message.getRawContent());
while(m.find())
invites.add(m.group(1));
LOG.trace("Found "+invites.size()+" invites.");
try{
for(String inviteCode : invites)
{
Invite invite = null;
try {
invite = Invite.resolve(message.getJDA(), inviteCode).complete();
} catch(Exception e) {}
if(invite==null || !invite.getGuild().getId().equals(message.getGuild().getId()))
{
if(settings.inviteAction == Action.DELETE)
shouldDelete = true;
else
{
String key = message.getAuthor().getId()+"|"+message.getGuild().getId();
OffsetDateTime lastWarning = warnings.get(key);
if(lastWarning==null || lastWarning.isBefore(message.getCreationTime().minusMinutes(1)))
{
shouldDelete = true;
message.getTextChannel().sendMessage(message.getMember().getAsMention()+": Please do not post invite links here.").queue();
warnings.put(key, message.getCreationTime());
}
else
{
AuditableRestAction ra = null;
switch(settings.inviteAction) {
case BAN:
ra = message.getGuild().getController().ban(message.getMember(), 1);
break;
case KICK:
shouldDelete = true;
ra = message.getGuild().getController().kick(message.getMember());
break;
case MUTE:
shouldDelete = true;
Role mutedRole = ModLogger.getMutedRole(message.getGuild());
if(mutedRole!=null)
ra = message.getGuild().getController().addRolesToMember(message.getMember(), mutedRole);
break;
case WARN:
shouldDelete = true;
break;
}
if(ra!=null)
{
ra.reason("Posting invite link: "+inviteCode);
ra.queue(v -> modlog.logAutomod(message, settings.inviteAction, "posting an invite link: ```\n"+inviteCode+" ```"));
}
}
}
break;
}
}
}catch(PermissionException ex){}
}
if(shouldDelete)
{
try{message.delete().reason("Automod").queue();}catch(PermissionException e){}
}
}
@Override
public void onReady(ReadyEvent event) {
jda = event.getJDA();
event.getJDA().getGuilds().forEach(g -> updateRoleSettings(g));
LOG.info("Done loading!");
}
@Override
public void onGuildMemberRoleAdd(GuildMemberRoleAddEvent event) {
if(event.getMember().equals(event.getGuild().getSelfMember()))
updateRoleSettings(event.getGuild());
}
public void updateRoleSettings(Guild guild)
{
try{
guild.getSelfMember().getRoles().stream().forEach(r -> {
if(r.getName().toLowerCase().startsWith("antimention"))
{
try{
int maxmentions = Integer.parseInt(r.getName().split(":",2)[1].trim());
if(maxmentions>MENTION_MINIMUM)
{
manager.setMaxMentions(guild, (short)maxmentions);
r.delete().queue();
//antimention.put(guild.getId(), maxmentions);
}
}catch(NumberFormatException | ArrayIndexOutOfBoundsException | NullPointerException | PermissionException e){}
}
else if(r.getName().toLowerCase().startsWith("antiinvite"))
{
try{
String type = r.getName().split(":",2)[1].trim().toUpperCase();
Action act = type==null ? Action.NONE : Action.of(type);
if(act!=Action.NONE)
{
manager.setInviteAction(guild, Action.of(type));
r.delete().queue();
}
}catch(Exception e){}
}
else if(r.getName().toLowerCase().startsWith("antispam"))
{
try{
String[] parts = r.getName().split(":")[1].trim().split("\\|");
int num = Integer.parseInt(parts[1].trim());
if(num>4)
{
manager.setSpam(guild, Action.of(parts[0].trim()), (short)num);
r.delete().queue();
}
}catch(Exception e){}
}
});
}catch(NullPointerException e) {
LOG.fatal("Somehow there was no selfmember on "+guild);
}
}
public static void sendPrivateMessageThen(User user, String message, Runnable runnable)
{
try{
user.openPrivateChannel().queue(
pc -> pc.sendMessage(message).queue(
m -> runnable.run(),
v -> runnable.run()),
v -> runnable.run());
} catch(PermissionException e) {
runnable.run();
}
}
private class RaidmodeStatus {
private int count;
private OffsetDateTime last;
private RaidmodeStatus()
{
count = 1;
last = OffsetDateTime.now();
}
private void update()
{
OffsetDateTime now = OffsetDateTime.now();
if(last.until(now, ChronoUnit.SECONDS) < 3)
count++;
else
count = 1;
last = now;
}
private long secondSinceLastJoin()
{
return last.until(OffsetDateTime.now(), ChronoUnit.SECONDS);
}
}
private class SpamStatus {
private String message;
private OffsetDateTime time;
private int count;
private final Message[] list = new Message[2];
private SpamStatus(Message message)
{
this.message = message.getRawContent().toLowerCase();
list[0] = message;
time = message.getCreationTime();
count = 1;
}
private int update(Message message)
{
String lower = message.getRawContent().toLowerCase();
if(lower.equals(this.message) && this.time.plusMinutes(1).isAfter(message.getCreationTime()))
{
count++;
if(count==4)
{
try{
list[0].delete().queue();
list[1].delete().queue();
}catch(Exception e){}
}
else if (count!=3)
list[1] = message;
time = message.getCreationTime();
return count;
}
else
{
this.message = lower;
time = message.getCreationTime();
count = 1;
list[0] = message;
list[1] = null;
return 1;
}
}
}
}